Skip to content

fix(runtime): honor hook body timeoutMs so nested cross-object writes aren't clamped to 250ms (#1867)#3232

Merged
os-zhuang merged 3 commits into
mainfrom
claude/sandbox-nested-cross-object-writes-nd5n0z
Jul 18, 2026
Merged

fix(runtime): honor hook body timeoutMs so nested cross-object writes aren't clamped to 250ms (#1867)#3232
os-zhuang merged 3 commits into
mainfrom
claude/sandbox-nested-cross-object-writes-nd5n0z

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

背景

#1867(P0):子对象的 afterInsert/afterUpdate 钩子里做嵌套跨对象写入(ctx.api.object('parent').update(...))会让 QuickJS 沙箱崩溃 —— memory access out of bounds。这条限制迫使五个模板把 header/rollup 字段改成手工维护的反规范化写法。

关键发现

崩溃本身在 main 上已经被修复了。 现在的沙箱采用 deferred-promise + pump 的宿主调用模型(host 调用不再走 asyncify,每次调用都跑在各自独立的 newAsyncContext() VM 里),所以 asyncify「同一栈不能二次展开」的限制不再触发。我用真实的 ObjectQL 引擎 + 真实 QuickJSScriptRunner 复现了 issue 的场景(子对象钩子写父对象、且父对象自身也带钩子 → 真正的嵌套 VM),4 层嵌套写入链约 65ms 完成,无崩溃、无超时。issue 复现所用的 9.5.1 早于这次重构。

真正遗留的可靠性瓶颈是超时钳制。 resolveTimeout 把引擎默认值(钩子 250ms)直接塞进了 Math.min(...),于是对钩子它永远是最小值:作者按 spec(ScriptBody.timeoutMs 允许最大 30_000ms)声明了更大的 timeoutMs、想给嵌套 rollup 留出时间,却被无声钳回 250ms 并在执行中途被 kill。这是一处「声明了但未强制执行」的缺口(AGENTS.md Prime Directive #10),正是把作者推向反规范化写法的那根稻草。

改动

packages/runtime/src/sandbox/quickjs-runner.tsresolveTimeout:

  • 引擎默认值改为兜底值(仅当调用方未提供任何显式超时时使用),不再是硬上限。
  • 显式的 body.timeoutMs(以及经 opts 传入的外层 hook/action 超时)会被尊重;两者都存在时取较小者 —— 与 spec「smaller of this and the enclosing hook/action timeout wins」一致。
  • 未声明的 body 仍拿到 250ms(钩子)/ 5000ms(action)默认值;body 仍可把自己的超时调低到默认值以下。
-    return Math.min(...[def, opts.timeoutMs, bodyTimeoutMs].filter((n): n is number => typeof n === 'number'));
+    const explicit = [opts.timeoutMs, bodyTimeoutMs].filter((n): n is number => typeof n === 'number');
+    return explicit.length > 0 ? Math.min(...explicit) : def;

测试

  • quickjs-runner.test.ts 新增 6 个用例:
    • 嵌套沙箱重入(单层、4 层链、并发 fan-out)不崩溃且返回正确;
    • 超时解析:尊重 body 声明的、高于默认值的 timeoutMs(600ms 的宿主调用在 5000ms 预算内完成);未声明时仍套用默认;仍可调低。
  • 新增 nested-write.integration.test.ts:真实 ObjectQL 引擎 + 沙箱,子对象 afterInsert/afterUpdate 钩子把总额 rollup 到父对象(父对象也带钩子 → 真正的嵌套 VM),无崩溃,得到正确的反规范化总额。

@objectstack/runtime 全量 564 个测试通过;tsc --noEmiteslint 均通过。附带 changeset(patch)。

关于 ctx.services.data

issue 提到「ctx.services.data 在沙箱里是 undefined(静默 no-op)」。这一项刻意不改:沙箱内数据访问的既定契约是 ctx.api(带 capability 门禁);再暴露一个 ctx.services.data 会形成第二套方言、并绕过 capability 门禁(违反 Prime Directive #5/#12)。既然 ctx.api 的嵌套写入现在可用,这个「替代方案」也就不需要了。沙箱里访问 ctx.services 会明确抛错,而非静默吞掉写入。

影响

嵌套跨对象写入(「子记录变化时更新父记录」)现在既安全又可靠,header/rollup 字段不再需要手工维护的反规范化 workaround。

🤖 Generated with Claude Code


Generated by Claude Code

… aren't clamped to 250ms (#1867)

Nested cross-object writes from a hook (`ctx.api.object('parent').update(...)`
from a child's afterInsert/afterUpdate) once crashed the QuickJS sandbox with
`memory access out of bounds` — the old single-suspended-asyncify host-call
model could not unwind the WASM stack twice. That crash is already fixed on main
by the deferred-promise + pump host-call model (host calls no longer asyncify;
each invocation gets its own isolated VM), so any depth of nested re-entrancy
now composes safely.

The remaining reliability blocker was the timeout: `resolveTimeout` folded the
engine default (250ms for hooks) straight into `Math.min(...)`, so it always
dominated. A hook body that declared a larger `timeoutMs` (the spec allows up to
30_000ms via `ScriptBody.timeoutMs`) to give a legitimate nested-write rollup
room to settle was silently clamped back to 250ms and killed mid-flight — a
declared-but-unenforced knob (AGENTS.md Prime Directive #10) that pushed authors
toward denormalized hand-maintained rollups.

The engine default is now a FALLBACK used only when no explicit timeout is
supplied, not a hard ceiling. An explicit `body.timeoutMs` (and/or an enclosing
hook/action timeout via opts) is honored; when both are present the smaller
wins. Unspecified bodies still get the 250ms hook / 5000ms action default, and a
body may still lower its own timeout below the default.

Tests:
- quickjs-runner.test.ts — nested sandbox re-entrancy (single, 4-level chain,
  concurrent fan-out) does not crash; timeout resolution honors a body's
  declared timeoutMs above the default, still applies the default when
  unspecified, and still lets a body lower it.
- nested-write.integration.test.ts — real ObjectQL engine + sandbox: a child
  afterInsert/afterUpdate hook rolls its total up to the parent (parent also has
  a hook → real nested VM) without crashing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 18, 2026 4:56pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

claude added 2 commits July 18, 2026 15:55
#1867)

Add a "Nested cross-object writes" note to the hook-bodies guide: a body may
write other objects (child afterInsert/afterUpdate → parent update), the target's
own hooks fire in a fresh sandbox VM, it composes to any depth, and a deeper or
slower rollup chain should declare a larger timeoutMs (up to 30s) rather than
rely on the 250ms single-write default. Documents the capability the runtime now
delivers instead of the denormalized workaround.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
…ollup (#1867)

Wire the real ObjectQL engine to the real SqlDriver (better-sqlite3, on-disk)
and drive the expense-template rollup pattern through the hook → sandbox →
nested-write path: an expense_line afterInsert/afterUpdate hook recomputes and
writes expense_report.total_amount. Confirms the child→parent nested write lands
the correct total on a real database with no `memory access out of bounds`
crash — the platform limit the templates' CHARTERs documented as forcing
denormalized workarounds.

Scoped to insert/update (where the hook can resolve the child FK); a comment
notes delete-safe aggregate rollups belong to the native `summary` field, since
an afterDelete hook receives only `{ id, options }` with no pre-image of the
deleted row's FK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
@os-zhuang
os-zhuang marked this pull request as ready for review July 18, 2026 16:10
@os-zhuang
os-zhuang merged commit d1d1c40 into main Jul 18, 2026
15 of 16 checks passed
@os-zhuang
os-zhuang deleted the claude/sandbox-nested-cross-object-writes-nd5n0z branch July 18, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants